interface Context {
  isPan: boolean;
  isTap: boolean;
  isPress: boolean;
  isFlick: boolean;
  isVertical: boolean;
  handler: number;
  startX: number;
  startY: number;
  points: {
    x: number;
    y: number;
    t: number;
  }[];
}

interface Point {
  clientX: number;
  clientY: number;
}

export class Listener {
  private contexts: Map<string | number, Context> = new Map();
  private isListeningMouse: boolean = false;

  constructor(private element: HTMLElement, private recognizer: Recognizer) {
    element.addEventListener('mousedown', event => {
      const context = Object.create(null);
      this.contexts.set('mouse' + (1 << event.button), context);
    
      this.recognizer.start(event, context);
    
      const mousemove = (event: MouseEvent) => {
        let button = 1;
        while (button <= event.buttons) {
          if (button & event.buttons) {
            let key = button;
            // order of buttons & button property is not same
            if (button == 2) {
              key = 4;
            } else if (button == 4) {
              key = 2;
            }
            let context = this.contexts.get('mouse' + key)!;
            this.recognizer.move(event, context);
          }
          button = button << 1;
        }
      };
    
      const mouseup = event => {
        let context = this.contexts.get('mouse' + (1 << event.button))!;
        this.recognizer.end(event, context);
        this.contexts.delete('mouse' + (1 << event.button));
    
        if (event.buttons === 0) {
          element.removeEventListener('mouseup', mouseup);
          element.removeEventListener('mousemove', mousemove);
          this.isListeningMouse = false;
        }
      };
    
      if (!this.isListeningMouse) {
        element.addEventListener('mouseup', mouseup);
        element.addEventListener('mousemove', mousemove);
        this.isListeningMouse = true;
      }
    });
    
    element.addEventListener('touchstart', event => {
      for (let touch of Array.from(event.changedTouches)) {
        const context = Object.create(null);
        this.contexts.set(touch.identifier, context);
        this.recognizer.start(touch, context);
      };
    });
    
    element.addEventListener('touchmove', event => {
      for (let touch of Array.from(event.changedTouches)) {
        const context = this.contexts.get(touch.identifier)!;
        this.recognizer.move(touch, context);
      }
    });
    
    element.addEventListener('touchend', event => {
      for (let touch of Array.from(event.changedTouches)) {
        const context = this.contexts.get(touch.identifier)!;
        this.recognizer.end(touch, context);
        this.contexts.delete(touch.identifier);
      }
    });
    
    element.addEventListener('touchcancel', event => {
      for (let touch of Array.from(event.changedTouches)) {
        const context = this.contexts.get(touch.identifier)!;
        this.recognizer.cancel(touch, context);
        this.contexts.delete(touch.identifier);
      }
    });
  }
}

export class Recognizer {
  constructor(private dispatcher: Dispatcher) {

  }

  start(point: Point, context: Context) {
    context.startX = point.clientX;
    context.startY = point.clientY;
    context.points = [{
      t: Date.now(),
      x: point.clientX,
      y: point.clientY,
    }];
  
    context.isPan = false;
    context.isTap = true;
    context.isPress = false;
  
    context.handler = window.setTimeout(() => {
      context.isPan = false;
      context.isTap = false;
      context.isPress = true;
      context.handler = -1;
      this.dispatcher.dispatchEvent('press', {});
    }, 500);
  }
  
  move(point: Point, context: Context) {
    let dx = point.clientX - context.startX;
    let dy = point.clientY - context.startY;
  
    if (!context.isPan && dx ** 2 + dy ** 2 > 100) {
      context.isPan = true;
      context.isTap = false;
      context.isPress = false;
      context.isVertical = Math.abs(dx) < Math.abs(dy);
      this.dispatcher.dispatchEvent('panStart', {
        startX: context.startX,
        startY: context.startY,
        clientX: point.clientX,
        clientY: point.clientY,
        isVertical: context.isVertical,
      });
      window.clearTimeout(context.handler);
    }
  
    if (context.isPan) {
      this.dispatcher.dispatchEvent('pan', {
        startX: context.startX,
        startY: context.startY,
        clientX: point.clientX,
        clientY: point.clientY,
        isVertical: context.isVertical,
      });
    }
  
    context.points = context.points.filter(point => Date.now() - point.t < 500);
  
    context.points.push({
      t: Date.now(),
      x: point.clientX,
      y: point.clientY,
    });
  }
  
  end(point: Point, context: Context) {
    if (context.isTap) {
      this.dispatcher.dispatchEvent('tap', {
        clientX: point.clientX,
        clientY: point.clientY,
      });
      window.clearTimeout(context.handler);
    }
  
    if (context.isPress) {
      this.dispatcher.dispatchEvent('pressEnd', {});
    }
  
    context.points = context.points.filter(point => Date.now() - point.t < 500);
  
    let v: number;
    if (context.points.length == 0) {
      v = 0;
    } else {
      let d = Math.sqrt((point.clientX - context.points[0].x) ** 2 +
      (point.clientY - context.points[0].y) ** 2);
      v = d / (Date.now() - context.points[0].t);
    }
  
    if (v > 1.5) {
      context.isFlick = true;
      this.dispatcher.dispatchEvent('flick', {
        startX: context.startX,
        startY: context.startY,
        clientX: point.clientX,
        clientY: point.clientY,
        isVertical: context.isVertical,
        isFlick: context.isFlick,
        velocity: v,
      });
    } else {
      context.isFlick = false;
    }
  
    this.dispatcher.dispatchEvent('panEnd', {
      startX: context.startX,
      startY: context.startY,
      clientX: point.clientX,
      clientY: point.clientY,
      isVertical: context.isVertical,
      isFlick: context.isFlick,
    });
  }
  
  cancel(point: Point, context: Context) {
    window.clearTimeout(context.handler);
    this.dispatcher.dispatchEvent('cancel', {});
  }
}

export class Dispatcher {
  constructor(private element: HTMLElement) {

  }
  
  dispatchEvent(type: string, properties: Record<string, any>) {
    const event = new Event(type);
    Object.assign(event, properties);
    this.element.dispatchEvent(event);
  }
}



export function enableGesture(element) {
  new Listener(element, new Recognizer(new Dispatcher(element)));
}